Skip to content

feat(policy): per-rule scanModalities for content-scanning conditions#12

Merged
scotty595 merged 1 commit intomainfrom
feat/per-policy-modalities
Apr 30, 2026
Merged

feat(policy): per-rule scanModalities for content-scanning conditions#12
scotty595 merged 1 commit intomainfrom
feat/per-policy-modalities

Conversation

@scotty595
Copy link
Copy Markdown
Contributor

@scotty595 scotty595 commented Apr 30, 2026

Why

The previous shape (closed in governance-cloud#4) put modality config on a global org-level toggle. That coupled unrelated policies and was nonsensical for metadata-only rules (rate_limit, token_budget, kill_switch).

Each policy that semantically scans content should declare its own modality coverage. A prompt_injection rule might want ['text', 'image']; a separate sensitive_data_filter rule might want only ['text']. Per-policy granularity, single shared OCR pass per request, no hidden coupling.

This PR is the SDK groundwork for that. Cloud-side UI + enforce-orchestrator wiring lands in a follow-up that imports the new types and helpers.

API surface

// PolicyRule
interface PolicyRule {
  // ...existing fields
  scanModalities?: Modality[]; // optional; only meaningful for content-scanning conditions
}

// EnforcementContext
interface EnforcementContext {
  // ...existing fields
  textByModality?: Partial<Record<Modality, string>>; // host populates
}

// New exports from governance-sdk/scan/multi-modal
export const CONDITIONS_SUPPORTING_MODALITIES: ReadonlySet<string>;
export function conditionSupportsModalities(conditionType: string): boolean;

// New export from governance-sdk (top-level via policy.ts)
export function getScanText(
  ctx: EnforcementContext,
  rule?: PolicyRule,
): string[] | null;  // null = "use the legacy fallback"

What changes in the engine

  • ConditionEvaluator widened to (ctx, params, rule?) => boolean. Structurally backward-compatible — existing (ctx, params) => boolean implementations satisfy the wider signature unchanged.
  • evaluateCondition, evaluate, evaluateStage thread the rule through so evaluators can read rule.scanModalities.
  • Six content-scanning evaluators updated to consult getScanText(ctx, rule) and fall back to their original behaviour when it returns null.
  • Combinators (any_of, all_of, not) synthesise a per-child rule view: preserves parent's scanModalities, rebinds condition to the nested type. So any_of over injection_guard + blocklist with scanModalities: ['image'] correctly scopes both sub-checks to image text.

Conditions that get scanModalities

Type Operates on
injection_guard extracted text from input
ml_injection_guard pre-computed ML score (host runs the classifier on the modality union)
blocklist extracted text
input_pattern extracted text (regex)
output_pattern extracted text (regex)
sensitive_data_filter extracted text (curated patterns)

Everything else — tool_blocked, cost_budget, concurrent_limit, time_window, agent_level, network_allowlist, scope_boundary, require_signed_identity, require_signed_action, length checks, combinators themselves — operates on metadata. The registry rejects scanModalities on those at the helper level so they remain inert when set.

Backward compatibility

  • Rules without scanModalities see exactly the same content as before. getScanText returns null, the evaluators fall back to extractStrings(ctx.input) / ctx.outputText / etc. — verified by the existing 1,399 tests passing unchanged.
  • ConditionEvaluator's third parameter is optional. Custom evaluators registered by callers keep working.
  • No changes to public type exports for existing fields.

What's NOT in this PR (follow-ups)

  1. Cloud policy editor UI — per-rule modality selector that only renders on rule types whose condition supports modalities. Imports conditionSupportsModalities from this PR.
  2. Cloud enforce orchestrator wiring — single scanMultiModal() call per request for the union of modalities across active rules, populates ctx.textByModality, then invokes enforce(). Plus a default extractor (Groq vision is the easiest reuse — the client's already wired for Tier-3).
  3. Cloud PATCH /api/v1/saved-policies validator — rejects scanModalities on unsupported condition types at write time. Uses the exported helper.

Tests

  • New src/per-rule-modalities.test.ts (14 tests):
    • Registry contents (six in, all metadata-only condition types out)
    • getScanText null paths (no rule / unsupported condition / unset modalities) and non-null paths (per-modality slices, missing-modality skip)
    • Per-rule dispatch: text-only rule does NOT match on image content and vice versa
    • Multi-rule independence (two rules scoped to different modalities)
    • scanModalities ignored on non-content-scanning rule (cost_budget proxy)
    • Combinator propagation (any_of with parent scanModalities=['image'] correctly catches a hit only when the target text is in the image modality)
  • 1,413 / 0 tests total (was 1,399 / 0 before this PR).

Test plan

  • npm run build clean
  • npm test 1,413 / 0
  • Reviewer skims src/policy.ts (PolicyRule, EnforcementContext, ConditionEvaluator, getScanText, evaluator-threading)
  • Reviewer skims src/conditions/builtins.ts (six evaluator wrappers + combinator child-rule synthesis)
  • Reviewer confirms the registry list in src/scan/multi-modal.ts is complete (no false negatives, no false positives)

🤖 Generated with Claude Code


Note

Medium Risk
Changes the policy engine evaluation signature and content-scanning condition behavior to optionally read modality-specific text, which can alter enforcement decisions if hosts start populating textByModality/scanModalities or if combinator propagation is incorrect.

Overview
Adds per-rule modality scoping for content-scanning policies by introducing PolicyRule.scanModalities and EnforcementContext.textByModality, plus a new getScanText() helper to select the modality-specific text (or fall back to legacy input/output scanning when unset).

Threads the parent PolicyRule through condition evaluation (ConditionEvaluator now accepts rule?) and updates the six content-scanning built-ins (injection_guard, ml_injection_guard docs, blocklist, input_pattern, output_pattern, sensitive_data_filter) to consult getScanText(). Combinators (any_of/all_of/not) now propagate the parent rule’s scanModalities into nested conditions.

Exports CONDITIONS_SUPPORTING_MODALITIES and conditionSupportsModalities() from scan/multi-modal, and adds a focused test suite covering helper behavior, legacy fallback, per-rule dispatch, non-content-rule no-ops, and combinator propagation.

Reviewed by Cursor Bugbot for commit 423e372. Bugbot is set up for automated code reviews on this repo. Configure here.

Previous design (closed in cloud PR #4) put modality config on a global
org-level toggle. That coupled unrelated policies and made no sense for
metadata-only rules (rate_limit, token_budget, kill_switch). Each policy
that semantically scans content should declare its own modality coverage.

This commit lays the SDK groundwork for that — cloud-side UI + enforce
wiring lands in a separate PR.

Added
- `scanModalities?: Modality[]` on `PolicyRule`. Optional, defaults to
  legacy behaviour. Only meaningful when the condition type is one of
  the six content-scanning types (`injection_guard`, `ml_injection_guard`,
  `blocklist`, `input_pattern`, `output_pattern`, `sensitive_data_filter`).
- `textByModality?: Partial<Record<Modality, string>>` on
  `EnforcementContext`. Host populates with pre-extracted text (typically
  by calling `scanMultiModal()` once for the union of modalities across
  active rules) before invoking enforce().
- `CONDITIONS_SUPPORTING_MODALITIES` (ReadonlySet) +
  `conditionSupportsModalities(type)` exported from
  `governance-sdk/scan/multi-modal`. The cloud's policy editor consults
  this to decide whether to render a modality selector for a given rule;
  validators reject `scanModalities` on rule types not in the set.
- `getScanText(ctx, rule)` exported from `governance-sdk` (top-level via
  policy.ts re-export). Returns per-modality text slices when the rule
  opts in, or `null` to signal "use the legacy input-walk fallback."
  This is the backward-compat seam.

Changed
- `ConditionEvaluator` widened to `(ctx, params, rule?) => boolean`.
  Structurally backward-compatible — existing `(ctx, params) => boolean`
  implementations still satisfy the wider signature.
- Engine threads the rule through `evaluateCondition`, `evaluate`, and
  `evaluateStage` so evaluators can read `rule.scanModalities`.
- Six content-scanning evaluators updated to consult `getScanText(ctx, rule)`
  and fall back to their original behaviour when null. Legacy rules
  without `scanModalities` see exactly the same content as before this
  feature shipped — verified by the existing 1,399 tests.
- Combinators (`any_of`, `all_of`, `not`) synthesise a per-child rule
  view that preserves the parent's `scanModalities` while rebinding
  `condition` to the nested type. This lets `getScanText()` correctly
  evaluate the CHILD's eligibility while still scoping to the PARENT's
  modality config — so an `any_of` over `injection_guard` + `blocklist`
  with `scanModalities: ["image"]` works end-to-end.

What this PR is NOT
- Cloud's policy editor UI for the modality selector — separate PR.
- Cloud's enforce orchestrator wiring (`scanMultiModal()` invocation +
  default extractor) — separate PR.
- Cloud-side validator rejecting scanModalities on unsupported conditions
  at PATCH time — separate PR (uses the exported helper).

Tests
- 14 new tests in src/per-rule-modalities.test.ts covering the
  registry contents, getScanText null/non-null paths, per-rule dispatch
  to textByModality, modality scoping (text-only rule does NOT match on
  image content and vice versa), multi-rule independence, scanModalities
  ignored on non-content-scanning rules, and combinator propagation.
- Full suite: 1,413 / 0 (was 1,399 / 0).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@scotty595 scotty595 merged commit 98d4d5e into main Apr 30, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant